Skip to content

feat(tron): add tx signing, fee estimation, and withdrawals#2714

Open
shamardy wants to merge 9 commits intotron-tokens-activationfrom
tron-fees-and-withdrawals
Open

feat(tron): add tx signing, fee estimation, and withdrawals#2714
shamardy wants to merge 9 commits intotron-tokens-activationfrom
tron-fees-and-withdrawals

Conversation

@shamardy
Copy link
Collaborator

Summary

Add transaction signing, fee estimation, and the full withdraw pipeline for TRX native and TRC20 token transfers. This is the fourth PR in the TRON integration series:

  1. feat(tron): initial groundwork for full TRON integration #2425 — Initial groundwork (ChainSpec, TronAddress)
  2. feat(tron): add HD wallet activation via EthCoin pipeline #2467 — HD wallet activation via EthCoin pipeline
  3. feat(tron): add TRC20 token activation and balance queries #2712 — TRC20 token activation and balance queries
  4. This PR — Transaction building, signing, fee estimation, and withdrawals

Key Features

  • Local protobuf transaction building using prost::Message derive macros — no dependency on node-side transaction creation
  • SHA256 + secp256k1 signing — same curve as EVM but with SHA256 digest (not Keccak256), TAPOS anti-replay (not nonces), and 65-byte r||s||v signatures with v ∈ {0,1}
  • Bandwidth + energy fee estimation — bandwidth charged per byte of serialized tx, energy charged per unit of smart contract execution (TRC20); both priced in SUN and converted to TRX at chain rate
  • New TxFeeDetails::Tron variant — reports bandwidth_used, energy_used, bandwidth_fee, energy_fee, total_fee in withdraw responses
  • Chain-dispatched broadcast via send_raw_tx — TRON protobuf hex routes to /wallet/broadcasthex, EVM RLP routes to eth_sendRawTransaction
  • Full withdraw support for both TRX and TRC20 in iguana and HD wallet modes, with max-send fee deduction for TRX and cross-asset fee verification for TRC20

Architecture

New modules under mm2src/coins/eth/tron/:

Module Purpose
proto.rs Minimal TRON protobuf types (Transaction, TransactionRaw, TransferContract, TriggerSmartContract) with correct non-sequential field tags
tx_builder.rs Unsigned transaction builders for TRX transfers and TRC20 transfer(address,uint256) calls, with TAPOS reference block computation
sign.rs sign_tron_transaction() — SHA256 digest → secp256k1 sign → 65-byte signature with recovery id normalization
fee.rs Bandwidth estimation from serialized tx size, energy estimation via triggerconstantcontract, fee calculation with resource deficit logic
withdraw.rs build_tron_trx_withdraw() with iterative max-send convergence, build_tron_trc20_withdraw() with cross-asset TRX balance verification

Modified files:

File Changes
eth/tron/api.rs get_account_resource(), broadcast_hex(), extended getnowblock for TAPOS block data
eth/eth_withdraw.rs TRON signing path replaces stub, TRON-specific fee estimation and memo rejection
eth.rs send_raw_tx chain dispatch (TRON → broadcast_hex, EVM → eth_sendRawTransaction)
lp_coins.rs / tx_fee_details.rs TxFeeDetails::Tron variant

Test Coverage

7 withdraw integration tests (cargo test --test mm2_tests_main --features tron-network-tests -- tron):

Test Coverage
test_trx_withdraw_and_send TRX withdraw + broadcast on Nile, exact spent_by_me/my_balance_change assertions
test_trc20_withdraw_and_send TRC20 USDT withdraw + broadcast, verifies fees paid in TRX (not token)
test_trx_withdraw_max Max TRX withdraw — fee deduction, total_amount + fee ≈ balance
test_trx_withdraw_hd HD wallet TRX withdraw from derivation index 1
test_trc20_withdraw_hd HD wallet TRC20 withdraw with energy fee verification
test_trx_withdraw_insufficient_balance Unfunded HD index 2 rejects with proper error
test_trx_fee_details_structure All TxFeeDetails::Tron fields present with correct types and values

Coin Configuration (unchanged from #2712)

{
  "coin": "TRX",
  "name": "tron",
  "fname": "TRON",
  "mm2": 1,
  "wallet_only": true,
  "chain_id": 728126428,
  "protocol": { "type": "TRON" }
}

TRON Roadmap (Next Steps)

This PR completes wallet-only TRON support. The following items remain for full TRON parity with EVM chains:

  • Atomic swap support — implement HTLC swap operations (SwapOps, MakerCoinSwapOpsV2, TakerCoinSwapOpsV2), order matching integration, and trade fee methods
  • Etomic swap contract for TVM — adapt EtomicSwapMakerV2/TakerV2 Solidity contracts for TVM deployment (TVM is EVM-compatible but uses different address format and energy model); deploy to Nile testnet → test → deploy to mainnet
  • Release full TRON integration — once swaps and contracts are complete, merge the full stack into dev and cut a release with TRX/TRC20 swap support
  • TRX staking (Stake V2) — allow users to stake TRX to acquire bandwidth and energy resources via TRON's Stake 2.0 mechanism (TIP-467), reducing transaction fees; includes staking, unstaking (14-day lock), and resource delegation
  • Dockerized TRON node for CI — run a local TRON node in Docker for deterministic integration tests (currently using public Nile testnet nodes which can be flaky)
  • Chain abstraction refactor — unify EVM and TRON under common RPC, transaction pipeline, broadcast session, and receipt finality abstractions; eliminate duplicated dispatch logic and move toward a typed chain model where invalid chain×asset combinations are unrepresentable
  • Remaining feature parity — WalletConnect signing support (TRON uses ETH protocol for WC, separate coin config), Trezor hardware wallet signing, MetaMask integration, TRON message signing/verification (different format from EVM), NFT support (TRC-721/TRC-1155), transaction history sync

Partially addresses #1542 and #1698.

🤖 Generated with Claude Code

shamardy and others added 9 commits February 12, 2026 22:53
Hand-written prost::Message structs for the minimal TRON transaction
protobuf types needed for TRX transfers and TRC20 interactions:
TransferContract, TriggerSmartContract, TransactionContract,
TransactionRaw (non-sequential tags), and Transaction.

Includes golden vector tests using real raw_data_hex from TRON
developer docs to validate wire-format compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Parse `blockID` (hex → [u8; 32]) and `timestamp` from the
`/wallet/getnowblock` response. Add `TaposBlockData` struct and
`get_block_for_tapos()` on both `TronHttpClient` and `TronApiClient`
(with node rotation) to provide validated inputs for TRON transaction
building.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…fers

Build TransactionRaw protobuf messages for native TRX transfers and
TRC20 token transfers. Reuses the shared ERC20_CONTRACT ABI for
transfer(address,uint256) encoding, matching the EVM code path.

Golden vector tests verify byte-exact output against real Nile testnet
transactions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add TRON fee estimation module with bandwidth/energy cost calculations
and chain prices API. Extract TxFeeDetails from lp_coins.rs into its
own module for better organization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add two TRON API primitives needed for fee estimation and transaction
broadcasting:

- get_account_resource(address): fetches bandwidth/energy quotas from
  /wallet/getaccountresource, mapping TRON's mixed-case proto3 JSON
  fields into the existing TronAccountResources domain type via an
  intermediate serde struct with exact #[serde(rename)] per field.

- broadcast_hex(tx_hex): submits signed protobuf bytes via
  /wallet/broadcasthex, returning BroadcastHexResponse { txid }.
  Error responses (result: false) handled by existing
  tron_error_from_value() in post().

Both methods available on TronHttpClient and TronApiClient (with
try_clients node rotation).

Also standardizes all request structs to visible: true (Base58
addresses) for consistency, and removes a duplicated
TriggerConstantContractRequest from integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dispatch EthCoin::send_raw_tx() and send_raw_tx_bytes() by ChainFamily:
- EVM: keeps existing eth_sendRawTransaction path
- TRON: validates input then routes to TronApiClient::broadcast_hex()

Input validation for TRON: strip 0x prefix, hex-only chars, even length,
256 KiB size cap. Empty payloads rejected on both string and bytes paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement the full TRON withdrawal flow integrated into EthWithdraw:
- TronWithdrawContext groups shared parameters for both transfer types
- build_tron_trx_withdraw: fee estimation with convergence loop for
  max-withdraw (handles varint-encoded amount affecting tx size/fee)
- build_tron_trc20_withdraw: energy estimation, fee_limit, and TRX
  balance check for fee coverage
- validate_tron_fee_policy: rejects EVM gas options for TRON
- build_tron_withdraw in EthWithdraw: orchestrates TRON-specific RPC
  calls, signing (protobuf), and TransactionDetails assembly
- Refactor build_transaction_details into shared method used by both
  EVM and TRON paths, eliminating duplicated spent/received logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…and doc fixes

- Add 7 withdraw integration tests against Nile testnet (TRX/TRC20,
  iguana/HD, max, insufficient balance, fee structure validation)
- Add pre-withdraw balance checks via my_balance RPC
- Use exact vec equality for from/to address assertions (matching
  ETH/Tendermint patterns)
- Assert spent_by_me, received_by_me, my_balance_change exactly
  (TRX: amount+fee, TRC20: amount only since fee is in TRX)
- Add TronApiClient-based on-chain verification helper with node failover
- Convert 24 deterministic unit tests to cross_test! for WASM compat
- Add module-level and struct docs for fee.rs and tx_fee_details.rs
- Add second Nile testnet node (api.nileex.io) for failover
- Feature-gate TRON_API_TIMEOUT: 10s production, 60s tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Collaborator

@mariocynicys mariocynicys left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!
First review iteration. Will need another one. Skipping most of the tests for now.

Comment on lines +3 to +4
//! Hand-written `prost::Message` structs matching TRON's `Tron.proto`,
//! `balance_contract.proto`, and `smart_contract.proto` definitions.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we include these files in our codebase instead and generate the rust structs from them?

Comment on lines +110 to +112
/// Transaction creation time in milliseconds since epoch.
#[prost(int64, tag = "14")]
pub timestamp: i64,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what/which epoch?

Comment on lines 352 to 366
/// Response from `/wallet/getnowblock`.
#[derive(Deserialize, Debug)]
pub struct GetNowBlockResponse {
/// Computed block identifier (not in protobuf — added by the HTTP servlet layer).
/// First 8 bytes duplicate the block number (big-endian) for sortability; remaining 24 bytes
/// are from SHA256 of `block_header.raw_data`. We only need bytes `[8..16]` for TAPOS
/// (`ref_block_hash`); the block number itself comes from `block_header.raw_data.number`.
/// Deserialized from a 64-char hex string; `None` if absent.
/// See [`generateBlockId`](https://github.com/tronprotocol/java-tron/blob/1e35f79/common/src/main/java/org/tron/common/utils/Sha256Hash.java#L252-L258).
#[serde(default, rename = "blockID", deserialize_with = "deserialize_opt_block_id")]
pub block_id: Option<[u8; 32]>,
/// Block header containing raw block data (number, timestamp, etc.).
#[serde(default)]
pub block_header: Option<BlockHeader>,
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: since the two fields inside are optional, in what cases would they be None? and can one of them be Some while the other isn't?

Comment on lines +384 to +386
/// Block timestamp in milliseconds since epoch.
#[serde(default)]
pub timestamp: i64,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as the other comment in proto.rs: what is since epoch? since the start of the current epoch i presume?

Comment on lines +894 to +907
/// Non-hex `blockID` must fail deserialization (triggers `BadResponse` → node rotation).
#[test]
fn parse_getnowblock_rejects_invalid_block_id_hex() {
let json = r#"{ "blockID": "not_valid_hex!!", "block_header": { "raw_data": { "number": 1 } } }"#;
assert!(serde_json::from_str::<GetNowBlockResponse>(json).is_err());
}

/// `blockID` that isn't exactly 32 bytes must fail deserialization.
#[test]
fn parse_getnowblock_rejects_wrong_length_block_id() {
// 31 bytes (62 hex chars) — too short
let json = r#"{ "blockID": "00000000033bab42e37d025dc14e9ebc26e8f6cb6b6e26e08d2bf2db29c3b4", "block_header": { "raw_data": { "number": 1 } } }"#;
assert!(serde_json::from_str::<GetNowBlockResponse>(json).is_err());
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's be more strict and not just check is_err(). let's check the error structure/variant.

Comment on lines +739 to +744
let timestamp = header.raw_data.timestamp;
if timestamp <= 0 {
return Err(
Web3RpcError::BadResponse(format!("TRON node returned invalid block timestamp: {timestamp}")).into(),
);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not also verify the timestamp in validated_header() like what we did with the block number.

Comment on lines +3 to +4
//! Free functions that build, estimate fees for, and prepare TRON withdrawal
//! transactions (TRX native and TRC20 token). Signing and `TransactionDetails`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does Free mean here?

Comment on lines +81 to +82
// amount), so changing the amount can change the fee. The `>=` (not `==`)
// break prevents infinite oscillation at varint boundaries, where reducing
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think we can even use ==. this will break for non-max withdraws.

Comment on lines +83 to +84
// the amount lowers the fee but increasing it raises the fee back. This may
// leave up to 1 bandwidth byte of dust (~1000 SUN) in that rare edge case.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think based on the structure of the logic here, this will always happen.

if the request is setting max: true, we can never match affordable >= amount_sun from the first try, so we need at least two iterations.

the critical question is:
is build_trx_transfer() always guaranteed to return a smaller-sized tx when the amount is decreased? can't the encoding for a smaller number be bigger in size than a bigger number?

edit: yup, protobuf's varint docs say that.

Comment on lines +127 to +133
/// Build TRC20 withdraw: estimate energy + bandwidth fees, return final tx raw.
pub async fn build_tron_trc20_withdraw(
ctx: &TronWithdrawContext<'_>,
tron: &TronApiClient,
contract_tron: &TronAddress,
amount_base_units: U256,
) -> Result<(TransactionRaw, TronTxFeeDetails, U256), MmError<WithdrawError>> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why no max support for trc20?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants